From 680cccf3ed83578c387f52ff36b0d762f9c869ee Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:10:00 +0900 Subject: ogp: 星空背景のデザインに刷新 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景をsharpで生成した星空に変更し、名前を大きくして 小さい表示でも読みやすくした。長いタイトルの自動縮小、 ホームのタグライン重複解消、キャッシュ更新(v=3)も含む。 --- src/pages/og/[slug].jpg.ts | 402 ++++++++++++++++++++++++++------------------- 1 file changed, 235 insertions(+), 167 deletions(-) (limited to 'src/pages/og/[slug].jpg.ts') diff --git a/src/pages/og/[slug].jpg.ts b/src/pages/og/[slug].jpg.ts index a34ae74..ca41c4a 100644 --- a/src/pages/og/[slug].jpg.ts +++ b/src/pages/og/[slug].jpg.ts @@ -10,6 +10,8 @@ import { } from "node:fs"; import path from "node:path"; +const W = 1200; +const H = 630; const contentDir = "src/content/docs"; const pages: Record = { @@ -23,6 +25,7 @@ const pages: Record = { "textlint-guide": "textlintで文章校正する方法", "bluebey": "ぶるべー スタジオ", "koubunsyo-kanri": "公文書管理の不正追及の軌跡", + "gyosei-fufuku-sinsa": "行政不服審査会の問題追及の軌跡", "ijime-judai-jitai": "いじめ重大事態への対応の軌跡", "fukushi-shisetsu-gyakutai": "障害者福祉施設における虐待通報対応の軌跡", "aiki-kouen": "合気公園の軌跡", @@ -83,45 +86,215 @@ const fontBuffer = readFileSync("node_modules/.noto-sans-jp.otf"); const faceiconBuffer = readFileSync("public/img/faceicon.jpg"); const faceiconBase64 = `data:image/jpeg;base64,${faceiconBuffer.toString("base64")}`; -// Generate star elements for space-like background -const stars = [ - { x: 80, y: 40, r: 2, o: 0.8 }, - { x: 200, y: 90, r: 1.5, o: 0.6 }, - { x: 350, y: 30, r: 2.5, o: 0.9 }, - { x: 500, y: 80, r: 1, o: 0.5 }, - { x: 650, y: 50, r: 2, o: 0.7 }, - { x: 800, y: 100, r: 1.5, o: 0.6 }, - { x: 950, y: 40, r: 3, o: 0.85 }, - { x: 1100, y: 70, r: 1, o: 0.5 }, - { x: 1150, y: 120, r: 2, o: 0.7 }, - { x: 150, y: 160, r: 1, o: 0.4 }, - { x: 450, y: 140, r: 2, o: 0.5 }, - { x: 700, y: 160, r: 1.5, o: 0.45 }, - { x: 1000, y: 150, r: 2.5, o: 0.55 }, - { x: 50, y: 500, r: 1.5, o: 0.4 }, - { x: 300, y: 550, r: 2, o: 0.5 }, - { x: 600, y: 520, r: 1, o: 0.35 }, - { x: 850, y: 570, r: 2.5, o: 0.5 }, - { x: 1050, y: 540, r: 1, o: 0.4 }, - { x: 1150, y: 580, r: 1.5, o: 0.45 }, - { x: 120, y: 300, r: 1, o: 0.3 }, - { x: 1080, y: 280, r: 1.5, o: 0.35 }, -].map((s, i) => ({ - type: "div" as const, - props: { - key: `star-${i}`, - style: { - position: "absolute" as const, - left: `${s.x}px`, - top: `${s.y}px`, - width: `${s.r * 2}px`, - height: `${s.r * 2}px`, - borderRadius: "50%", - background: `rgba(255,255,255,${s.o})`, - boxShadow: `0 0 ${s.r * 3}px rgba(255,255,255,${s.o * 0.4})`, +const NAME = "小平市議 安竹洋平"; +const LABEL = "KODAIRA CITY COUNCIL"; +const TAGLINE = "だれもが しあわせに 暮らせるまちへ"; + +// 星空背景は全ページ共通なので、ビルド中に1回だけ生成して使い回す。 +function mulberry32(a: number) { + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const baseGradient = () => + Buffer.from( + ``, + ); + +const nebulaSvg = () => + Buffer.from( + ``, + ); + +const milkyWay = () => + Buffer.from( + ``, + ); + +const arcsSvg = () => + Buffer.from( + ``, + ); + +function starsSvg(seed: number, n: number) { + const rnd = mulberry32(seed); + let s = ``; + for (let i = 0; i < n; i++) { + const x = rnd() * W; + const y = rnd() * H; + const r = 0.35 + rnd() * 1.8; + const o = 0.18 + rnd() * 0.75; + const col = rnd() > 0.82 ? "#dbeafe" : "#ffffff"; + s += ``; + } + for (let i = 0; i < 8; i++) { + const x = 520 + rnd() * 660; + const y = 40 + rnd() * 550; + const size = 5 + rnd() * 9; + const o = 0.6 + rnd() * 0.38; + const col = rnd() > 0.5 ? "#e0f2fe" : "#ffffff"; + s += ``; + const q = size * 0.22; + s += ``; + } + s += ``; + return Buffer.from(s); +} + +const leftOverlay = () => + Buffer.from( + ``, + ); + +async function buildBackground(): Promise { + const base = await sharp(baseGradient()).png().toBuffer(); + const neb = await sharp(nebulaSvg()).blur(80).png().toBuffer(); + const mw = await sharp(milkyWay()).blur(55).png().toBuffer(); + return sharp(base) + .composite([ + { input: neb }, + { input: mw, blend: "screen" }, + { input: arcsSvg() }, + { input: starsSvg(20261104, 260) }, + { input: leftOverlay() }, + ]) + .png() + .toBuffer(); +} + +let backgroundPromise: Promise | null = null; +function getBackground(): Promise { + if (!backgroundPromise) backgroundPromise = buildBackground(); + return backgroundPromise; +} + +function textLayer(title: string) { + // 長いタイトルでもはみ出さないよう、文字数に応じて少しだけ縮める + const titleSize = title.length > 40 ? 46 : title.length > 30 ? 52 : 60; + // ホームはタイトルとタグラインが実質同じなので、タグラインを省く + const isHome = + title.replace(/\s/g, "") === TAGLINE.replace(/\s/g, ""); + + return { + type: "div", + props: { + style: { + display: "flex", + width: `${W}px`, + height: `${H}px`, + alignItems: "center", + padding: "64px 80px", + gap: "56px", + position: "relative", + fontFamily: "Noto", + color: "#ffffff", + }, + children: [ + { + type: "div", + props: { + style: { + display: "flex", + padding: "6px", + borderRadius: "50%", + background: "linear-gradient(135deg,#22d3ee,#818cf8)", + flexShrink: "0", + }, + children: [ + { + type: "img", + props: { + src: faceiconBase64, + width: 168, + height: 168, + style: { borderRadius: "50%" }, + }, + }, + ], + }, + }, + { + type: "div", + props: { + style: { + display: "flex", + flexDirection: "column", + gap: "18px", + flex: "1", + }, + children: [ + { + type: "div", + props: { + style: { + fontSize: "22px", + letterSpacing: "3.5px", + color: "rgba(214,224,255,0.85)", + }, + children: LABEL, + }, + }, + { + type: "div", + props: { + style: { + fontSize: `${titleSize}px`, + fontWeight: 700, + lineHeight: 1.16, + letterSpacing: "-0.01em", + }, + children: title, + }, + }, + { + type: "div", + props: { + style: { + width: "128px", + height: "7px", + borderRadius: "4px", + background: "linear-gradient(90deg,#22d3ee,#818cf8)", + }, + }, + }, + ...(isHome + ? [] + : [ + { + type: "div", + props: { + style: { fontSize: "30px", color: "#c7d2fe" }, + children: TAGLINE, + }, + }, + ]), + { + type: "div", + props: { + style: { + display: "flex", + alignItems: "center", + paddingTop: "16px", + borderTop: "1px solid rgba(255,255,255,0.22)", + fontSize: "31px", + fontWeight: 700, + color: "#ffffff", + }, + children: [{ type: "div", props: { children: NAME } }], + }, + }, + ], + }, + }, + ], }, - }, -})); + }; +} export async function GET({ params }: { params: { slug: string } }) { const slug = params.slug; @@ -159,7 +332,11 @@ export async function GET({ params }: { params: { slug: string } }) { } const { createHash } = await import("node:crypto"); - const titleHash = createHash("md5").update(title).digest("hex"); + // デザインを変えたらこのバージョンを上げる(キャッシュを無効化する) + const DESIGN_VERSION = "space-v9b-3"; + const titleHash = createHash("md5") + .update(`${DESIGN_VERSION}|${title}`) + .digest("hex"); if (existsSync(cacheFile) && cache[slug] === titleHash) { const cachedBuffer = readFileSync(cacheFile); @@ -171,135 +348,26 @@ export async function GET({ params }: { params: { slug: string } }) { }); } - const svg = await satori( - { - type: "div", - props: { - style: { - display: "flex", - width: "1200px", - height: "630px", - background: - "linear-gradient(135deg, #0f0d2e 0%, #1e1b4b 30%, #3730a3 60%, #4f46e5 100%)", - color: "#ffffff", - fontFamily: "sans-serif", - padding: "64px 80px", - alignItems: "center", - gap: "56px", - position: "relative", - overflow: "hidden", - }, - children: [ - ...stars, - { - type: "img", - props: { - src: faceiconBase64, - width: 180, - height: 180, - style: { - borderRadius: "50%", - border: "3px solid rgba(255,255,255,0.25)", - flexShrink: "0", - position: "relative", - }, - }, - }, - { - type: "div", - props: { - style: { - display: "flex", - flexDirection: "column", - gap: "20px", - flex: "1", - position: "relative", - }, - children: [ - { - type: "div", - props: { - style: { - fontSize: "64px", - fontWeight: "700", - lineHeight: "1.2", - letterSpacing: "-0.02em", - }, - children: title, - }, - }, - { - type: "div", - props: { - style: { - display: "flex", - gap: "14px", - fontSize: "32px", - fontWeight: "500", - color: "#a5b4fc", - }, - children: [ - { type: "div", props: { children: "だれもが" } }, - { - type: "div", - props: { - style: { color: "#c7d2fe" }, - children: "しあわせに", - }, - }, - { - type: "div", - props: { - style: { color: "#e0e7ff" }, - children: "暮らせるまちへ", - }, - }, - ], - }, - }, - { - type: "div", - props: { - style: { - display: "flex", - gap: "16px", - fontSize: "26px", - fontWeight: "400", - color: "rgba(255,255,255,0.45)", - paddingTop: "12px", - borderTop: "1px solid rgba(255,255,255,0.1)", - }, - children: [ - { type: "div", props: { children: "小平市議 安竹洋平" } }, - { - type: "div", - props: { - style: { opacity: "0.6" }, - children: "yasutakeyohei.com", - }, - }, - ], - }, - }, - ], - }, - }, - ], - }, - } as any, - { - width: 1200, - height: 630, - fonts: [ - { name: "sans-serif", data: fontBuffer, weight: 400, style: "normal" }, - ], - }, - ); + const bg = await getBackground(); + + const svg = await satori(textLayer(title) as any, { + width: W, + height: H, + fonts: [ + { name: "Noto", data: fontBuffer, weight: 400, style: "normal" }, + { name: "Noto", data: fontBuffer, weight: 700, style: "normal" }, + ], + }); + + const pngBuffer = new Resvg(svg, { fitTo: { mode: "width", value: W } }) + .render() + .asPng(); - const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }); - const pngBuffer = resvg.render().asPng(); - // OGP は JPEG で出力する(PNGだと1枚300KBを超えるため、JPEGで約1/5にする) - const jpgBuffer = await sharp(pngBuffer).jpeg({ quality: 88 }).toBuffer(); + // テキスト層を星空背景に重ねて JPEG で出力する + const jpgBuffer = await sharp(bg) + .composite([{ input: pngBuffer }]) + .jpeg({ quality: 88 }) + .toBuffer(); // Save to cache in node_modules/.og-cache (persists across builds) if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); -- cgit v1.3.1